Skip to main content

Real-Time Data via WebSocket

The Molecule platform exposes a WebSocket endpoint that allows your application to receive real-time telemetry data from a connected gateway. This guide covers everything you need to establish a connection, authenticate, and handle live data.


Overview

The connection flow has three stages:

  1. Authenticate - obtain a JWT from the Molecule identity server
  2. Connect - open a WebSocket and send a binary handshake containing your gateway ID and JWT
  3. Receive - handle incoming binary frames from the gateway

Prerequisites

RequirementDetail
Gateway IDProvided by your Molecule account manager
Molecule credentialsUsername and password for id.moleculesystems.com
WebSocket endpointwss://wsau.moleculesystems.com/ws

Step 1: Authenticate

Obtain a JWT from the Molecule identity server using the password grant:

const authenticate = async (username, password) => {
const response = await fetch('https://id.moleculesystems.com/connect/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
username,
password,
client_id: 'molecule-api',
grant_type: 'password',
}),
});

const data = await response.json();

if (!response.ok || data.error)
throw new Error('Authentication failed');

return {
token: data.access_token,
expiresAt: Date.now() + data.expires_in * 1000,
};
};

Store the token securely. It must be included in the WebSocket handshake in Step 3.

Token expiry

Tokens expire after a fixed period. Always check expiresAt before connecting. If the token has expired, re-authenticate before opening the WebSocket.


Step 2: Connect

Open a WebSocket connection to the Molecule endpoint:

const ws = new WebSocket('wss://wsau.moleculesystems.com/ws');
ws.binaryType = 'arraybuffer'; // required- all frames are binary
important

You must set binaryType = 'arraybuffer' before the connection opens, otherwise binary frames will not be parsed correctly.


Step 3: Send the Handshake

Immediately after the connection opens, send a binary handshake packet. This identifies which gateway you want to connect to and proves your identity via the JWT.

Packet format

[0x01][0x02][idLen Hi][idLen Lo][gatewayId bytes][tokLen Hi][tokLen Lo][token bytes][0x01][0x04]
FieldSizeDescription
0x01 0x022 bytesClient connection marker
idLen2 bytesGateway ID length (big-endian)
gatewayIdvariableUTF-8 encoded gateway ID
tokLen2 bytesToken length (big-endian)
tokenvariableUTF-8 encoded JWT
0x01 0x042 bytesEnd-of-message sentinel

Implementation

const buildHandshake = (gatewayId, token) => {
const idBytes = new TextEncoder().encode(gatewayId);
const tokBytes = new TextEncoder().encode(token);

// total = 2 (marker) + 2 (idLen) + id + 2 (tokLen) + token + 2 (sentinel)
const buf = new Uint8Array(2 + 2 + idBytes.length + 2 + tokBytes.length + 2);
let i = 0;

buf[i++] = 0x01;
buf[i++] = 0x02;

buf[i++] = (idBytes.length >> 8) & 0xFF;
buf[i++] = idBytes.length & 0xFF;
buf.set(idBytes, i); i += idBytes.length;

buf[i++] = (tokBytes.length >> 8) & 0xFF;
buf[i++] = tokBytes.length & 0xFF;
buf.set(tokBytes, i); i += tokBytes.length;

buf[i++] = 0x01;
buf[i++] = 0x04;

return buf;
};

ws.onopen = () => {
const handshake = buildHandshake(gatewayId, token);
ws.send(handshake);
};

Step 4: Handle the Auth Response

The server responds with a 3-byte binary frame to confirm the handshake result:

[requestType][status][0x04]
ByteValueMeaning
requestType2Remote access response
status1Authenticated successfully
status0Authentication failed
0x040x04End of frame
ws.onmessage = (event) => {
const data = new Uint8Array(event.data);

// Auth response- 3 bytes ending in 0x04
if (data.length === 3 && data[2] === 0x04) {
const requestType = data[0];
const success = data[1];

if (requestType === 2 && success === 1) {
console.log('Connected and authenticated');
// ready to receive telemetry
} else {
console.error('Auth failed- check gateway ID and token');
ws.close();
}
return;
}

// Telemetry frames arrive here
handleTelemetry(data);
};

Step 5: Receive Telemetry

After a successful auth response, the server forwards data frames from the gateway. All frames share a common structure:

Frame format

[messageType][reserved][payload bytes...]
ByteDescription
byte 0Message type
byte 1Reserved
byte 2+UTF-8 payload (may have a trailing 0x00 null terminator)

Message types

TypeDescription
100Real-time telemetry JSON- update your live data display
1Command response- reply to a command you sent
2Device log- plain text log line from the gateway
otherUnknown- inspect raw bytes

Decoding frames

ws.onmessage = (event) => {
const bytes = new Uint8Array(event.data);

// Auth response- handle before anything else
if (!connected) {
const success = bytes[1] === 1;
if (success) {
connected = true;
console.log('Authenticated');
} else {
console.error('Auth failed');
ws.close();
}
return;
}

if (bytes.length < 1) return;

const messageType = bytes[0];

// Decode payload- strip 2-byte header, strip trailing null if present
let text;
try {
const raw = bytes.slice(2);
const payload = raw[raw.length - 1] === 0x00 ? raw.slice(0, raw.length - 1) : raw;
text = new TextDecoder('utf-8', { fatal: true }).decode(payload);
} catch {
console.warn('Decode error');
return;
}

if (messageType === 100) {
// Real-time telemetry
handleTelemetry(text);
} else if (messageType === 1) {
// Command response
console.log('Response:', text);
} else if (messageType === 2) {
// Device log line
console.log('Device log:', text);
} else {
console.log('Unknown type', messageType, bytes);
}
};

Real-time telemetry payload (type 100)

The payload is a JSON array of key-value items:

[
{ "key": "SOC", "lbl": "State of Charge", "val": "74.5" },
{ "key": "GRID", "lbl": "Grid Power", "val": "3200" }
]
FieldDescription
keyUnique identifier for this data point
lblHuman-readable label- only sent periodically to reduce bandwidth
valCurrent value as a string
const labels = {}; // cache key → label

const handleTelemetry = (text) => {
try {
const items = JSON.parse(text);
items.forEach(item => {
if (item.lbl) labels[item.key] = item.lbl; // cache when present
const label = labels[item.key] ?? item.key;
console.log(`${label}: ${item.val}`);
});
} catch (err) {
console.warn('Telemetry parse error', err);
}
};
Label frequency

lbl is only included periodically- not on every frame- to reduce bandwidth. Always cache it the first time you see it for a given key and reuse it when subsequent frames omit it.


Complete Example

class MoleculeWS {
constructor(gatewayId, token) {
this.gatewayId = gatewayId;
this.token = token;
this.ws = null;
this.labels = {}; // cache for key → label
}

connect() {
this.ws = new WebSocket('wss://wsau.moleculesystems.com/ws');
this.ws.binaryType = 'arraybuffer';

this.ws.onopen = () => this._sendHandshake();
this.ws.onmessage = (e) => this._onMessage(e);
this.ws.onerror = (e) => console.error('WS error', e);
this.ws.onclose = (e) => console.log('WS closed', e.code, e.reason);
}

disconnect() {
this.ws?.close();
}

_sendHandshake() {
const idBytes = new TextEncoder().encode(this.gatewayId);
const tokBytes = new TextEncoder().encode(this.token);
const buf = new Uint8Array(2 + 2 + idBytes.length + 2 + tokBytes.length + 2);
let i = 0;
buf[i++] = 0x01; buf[i++] = 0x02;
buf[i++] = (idBytes.length >> 8) & 0xFF;
buf[i++] = idBytes.length & 0xFF;
buf.set(idBytes, i); i += idBytes.length;
buf[i++] = (tokBytes.length >> 8) & 0xFF;
buf[i++] = tokBytes.length & 0xFF;
buf.set(tokBytes, i); i += tokBytes.length;
buf[i++] = 0x01; buf[i++] = 0x04;
this.ws.send(buf);
}

_onMessage(event) {
const data = new Uint8Array(event.data);

// Auth response- first message after handshake
if (!this.connected) {
if (data[1] === 1) {
this.connected = true;
console.log('Authenticated');
} else {
console.error('Auth failed');
this.ws.close();
}
return;
}

if (data.length < 1) return;

const messageType = data[0];

// Decode payload- strip 2-byte header and trailing null
let text;
try {
const raw = data.slice(2);
const payload = raw[raw.length - 1] === 0x00 ? raw.slice(0, raw.length - 1) : raw;
text = new TextDecoder('utf-8', { fatal: true }).decode(payload);
} catch {
return;
}

if (messageType === 100) this._handleTelemetry(text);
else if (messageType === 1) console.log('Response:', text);
else if (messageType === 2) console.log('Device log:', text);
else console.log('Unknown type', messageType);
}

_handleTelemetry(text) {
try {
const items = JSON.parse(text);
items.forEach(item => {
if (item.lbl) this.labels[item.key] = item.lbl;
const label = this.labels[item.key] ?? item.key;
console.log(`${label}: ${item.val}`);
});
} catch (err) {
console.warn('Telemetry parse error', err);
}
}
}

// Usage
const auth = await authenticate('user@example.com', 'password');
const client = new MoleculeWS('YOUR_GATEWAY_ID', auth.token);
client.connect();

Error Handling

ScenarioRecommended action
Auth response success = 0Re-authenticate and retry
ws.onerror firesLog the error, attempt reconnect after delay
ws.onclose with code 1008Token rejected- re-authenticate
ws.onclose with code 1009Handshake too large- contact support
Token expired before connectRe-authenticate before calling connect()

Reconnection with backoff

const connectWithRetry = async (gatewayId, token, attempt = 0) => {
const delay = Math.min(1000 * 2 ** attempt, 30000); // cap at 30s
await new Promise(r => setTimeout(r, delay));

const client = new MoleculeWS(gatewayId, token);
client.ws.onclose = async (e) => {
if (e.code !== 1000) { // 1000 = normal close
console.log(`Reconnecting in ${delay}ms...`);
await connectWithRetry(gatewayId, token, attempt + 1);
}
};
client.connect();
};

React Hook

If you are building a React application, here is a ready-to-use hook:

import { useEffect, useRef, useCallback } from 'react';

interface TelemetryItem {
key: string;
lbl?: string;
val: string;
}

export const useMoleculeWS = (
gatewayId: string,
token: string,
onTelemetry: (items: TelemetryItem[]) => void,
) => {
const wsRef = useRef<WebSocket | null>(null);
const labelsRef = useRef<Record<string, string>>({});

const connect = useCallback(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) return;

const ws = new WebSocket('wss://wsau.moleculesystems.com/ws');
ws.binaryType = 'arraybuffer';
wsRef.current = ws;

ws.onopen = () => {
const idBytes = new TextEncoder().encode(gatewayId);
const tokBytes = new TextEncoder().encode(token);
const buf = new Uint8Array(2 + 2 + idBytes.length + 2 + tokBytes.length + 2);
let i = 0;
buf[i++] = 0x01; buf[i++] = 0x02;
buf[i++] = (idBytes.length >> 8) & 0xFF;
buf[i++] = idBytes.length & 0xFF;
buf.set(idBytes, i); i += idBytes.length;
buf[i++] = (tokBytes.length >> 8) & 0xFF;
buf[i++] = tokBytes.length & 0xFF;
buf.set(tokBytes, i); i += tokBytes.length;
buf[i++] = 0x01; buf[i++] = 0x04;
ws.send(buf);
};

ws.onmessage = (event) => {
const data = new Uint8Array(event.data);

if (data.length === 3 && data[2] === 0x04) return; // auth response

try {
const payload = data[data.length - 1] === 0x00
? data.slice(0, data.length - 1) : data;
const items: TelemetryItem[] = JSON.parse(
new TextDecoder('utf-8', { fatal: true }).decode(payload)
);
items.forEach(item => {
if (item.lbl) labelsRef.current[item.key] = item.lbl;
});
onTelemetry(items);
} catch { /* ignore malformed frames */ }
};
}, [gatewayId, token, onTelemetry]);

const disconnect = useCallback(() => {
wsRef.current?.close();
wsRef.current = null;
}, []);

useEffect(() => () => { wsRef.current?.close(); }, []);

return { connect, disconnect };
};

Security Notes

  • The JWT is transmitted inside the encrypted WebSocket (wss://) connection and never appears in URLs or HTTP headers
  • Tokens have a limited lifetime- do not cache them beyond their expires_in value
  • Never log or store the raw JWT in a place accessible to other users
  • If a token is compromised, contact Molecule support to revoke it immediately